///
/// Permission to use, copy, modify, and/or distribute this software for any
/// purpose with or without fee is herby granted.
///
/// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
/// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
/// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
/// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
/// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
/// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
/// OR IN CONNECTION WITH THE USE OR PEFORMANCE OF THIS SOFTWARE.
///

///
/// An incomplete single-file INI parser for D.
///
/// The API should be similar to python's configparse module.  Internally it
/// uses the standard D associative array.
///
/// Example:
/// ---
/// import configparser;
///
/// auto config = new ConfigParser();
/// // no sections initially
/// assert(config.sections.length == 0);
/// // Section names ("Program Settings") are case-sensitive
/// conf.addSection("Storage Paths");
/// // Option names ("CONFIG_PATH") are case-insensitive
/// // (internally, they are all converted to lower-case)
/// conf.set("Program Settings", "CONFIG_PATH", "/home/user/.local/config");
/// ---
///
/// Authors: Mio
/// Date: 2023-11-11
/// Homepage: https://codeberg.org/supercell/mlib
/// License: 0BSD
/// Version: 0.5.2
///
/// History:
///      0.5.2 Make sections() and options() to be @trusted
///      0.5.1 Implement ConfigParserException
///      0.5   Add .write(string), .write(OutputRange), and .readString(string)
///      0.4   Add .write(File)
///      0.3   Fix option values not always being treated as lowercase.
///      0.2   Add .getBool()
///      0.1   Initial release
///
module mlib.configparser;

import std.conv : ConvException;
import std.stdio : File;
import std.range.primitives : isOutputRange;

public class ConfigParserException : Exception
{
   this(string msg) @safe pure
   {
      super(msg);
   }
}

public class DuplicateSectionException : ConfigParserException
{
   private string m_section;

   this(string section) @safe pure
   {
      const msg = "Section " ~ section ~ " already exists.";
      m_section = section;
      super(msg);
   }

   ///
   /// The section that caused this exception.
   ///
   string section() const @safe pure
   {
      return m_section;
   }
}

///
/// An exception that is thrown by a strict parser which indicates
/// that an option appears twice within any one section.
///
public class DuplicateOptionException : ConfigParserException
{
   private string m_option;
   private string m_section;

   this(string option, string section) @safe pure
   {
      const msg = "Option " ~ option ~ " in section " ~ section ~ " already exists.";
      m_option = option;
      m_section = section;
      super(msg);
   }

   ///
   /// The option that caused this exception.
   ///
   string option() const @safe pure
   {
      return m_option;
   }

   ///
   /// The section that the responsible option is a part of.
   ///
   string section() const @safe pure
   {
      return m_section;
   }
}

///
/// An exception that is thrown when a specified section could not be
/// found.
///
public class NoSectionException : ConfigParserException
{
   private string m_section;

   this(string section) @safe pure
   {
      const msg = "Section '" ~ section ~ "' does not exist.";
      m_section = section;
      super(msg);
   }

   ///
   /// The section that could not be found.
   ///
   string section() const @safe pure
   {
      return m_section;
   }
}

///
/// An exception that is thrown when a specified option could not be
/// found in the specified section.
///
public class NoOptionException : ConfigParserException
{
   private string m_section;
   private string m_option;

   this(string section, string option) @safe pure
   {
      const msg = "Section '" ~ section ~ "' does not have option '" ~ option ~ "'.";
      m_section = section;
      m_option = option;
      super(msg);
   }

   ///
   /// The section that was specified.
   ///
   string section() const @safe pure
   {
      return m_section;
   }

   ///
   /// The option that could not be found.
   ///
   string option() const @safe pure
   {
      return m_option;
   }
}

///
/// The main configuration parser.
///
public class ConfigParser
{
   private char[] m_delimiters;
   private char[] m_commentPrefixes;
   private bool m_strict;

   /** current section for parsing */
   private string m_currentSection;
   private string[string][string] m_sections;

   ///
   /// Creates a new instance of ConfigParser.
   ///
   /// Params:
   ///   delimiters = The characters used to divide keys from values.
   ///   commentPrefixes = The characters used to prefix comments in
   ///                     otherwise empty lines.
   ///   strict = Should the parser prevent any duplicate sections or
   ///            options when reading from a single source.
   ///
   this(char[] delimiters = ['=', ':'], char[] commentPrefixes = ['#', ';'],
      bool strict = true) @safe pure
   {
      m_delimiters = delimiters;
      m_commentPrefixes = commentPrefixes;
      m_strict = strict;
   }

   ///
   /// Return an array containing the available sections.
   ///
   string[] sections() const @trusted pure
   {
      return m_sections.keys();
   }

   ///
   @safe pure unittest
   {
      auto conf = new ConfigParser();

      assert(0 == conf.sections().length);

      conf.addSection("Section");

      assert(1 == conf.sections().length);
   }

   ///
   /// Add a section named `section` to the instance.
   ///
   /// Throws:
   ///   - DuplicateSectionError if a section by the given name already
   ///     exists.
   ///
   void addSection(string section) @safe pure
   {
      if (section in m_sections)
      {
         throw new DuplicateSectionException(section);
      }
      m_sections[section] = null;
   }

   ///
   @safe pure unittest
   {
      import std.exception : assertNotThrown, assertThrown;

      auto conf = new ConfigParser();

      /* doesn't yet exist */
      assertNotThrown!DuplicateSectionException(conf.addSection("sample"));
      /* already exists */
      assertThrown!DuplicateSectionException(conf.addSection("sample"));
   }

   ///
   /// Indicates whether the named `section` is present in the configuration.
   ///
   /// Params:
   ///   section = The section to check for in the configuration.
   ///
   /// Returns: `true` if the section exists, `false` otherwise.
   ///
   bool hasSection(string section) @safe pure const
   {
      return (section in m_sections) !is null;
   }

   ///
   @safe pure unittest
   {
      auto conf = new ConfigParser();
      conf.addSection("nExt");
      assert(true == conf.hasSection("nExt"), "Close the world.");
      assert(false == conf.hasSection("world"), "Open the nExt.");
   }

   ///
   /// Returns a list of options available from the specified *section*.
   ///
   /// Throws:
   ///  - NoSectionException if the specified exception does not exist.
   ///
   string[] options(string section) @trusted pure const
   {
      if (false == this.hasSection(section))
      {
         throw new NoSectionException(section);
      }
      return m_sections[section].keys();
   }

   ///
   @safe pure unittest
   {
      import std.exception : assertNotThrown, assertThrown;

      auto conf = new ConfigParser();

      conf.addSection("Settings");

      assertNotThrown!NoSectionException(conf.options("Settings"));
      assertThrown!NoSectionException(conf.options("void"));

      string[] options = conf.options("Settings");
      assert(0 == options.length, "More keys than we need");
   }

   ///
   /// If the given *section* exists, and contains the given *option*,
   /// return true; otherwise return false.
   ///
   bool hasOption(string section, string option) @safe pure const
   {
      import std.string : toLower;

      if (false == this.hasSection(section))
      {
         return false;
      }

      const lowercaseOption = toLower(option);
      return (lowercaseOption in m_sections[section]) !is null;
   }

   ///
   @safe pure unittest
   {
      auto writer = new ConfigParser();

      writer.addSection("valid_section");
      writer.set("valid_section", "valid_option", "value");

      assert(false == writer.hasOption("invalid_section", "invalid_option"));
      assert(false == writer.hasOption("valid_section", "invalid_option"));
      assert(true == writer.hasOption("valid_section", "valid_option"));
   }

   /*
   string[] read(string[] filenames)
   {
      return null;
   }*/

   ///
   /// Attempt to read and parse configuration data from a file
   /// specified by *filename*.
   ///
   void read(string filename) @safe
   {
      File file = File(filename, "r");
      scope(exit) { file.close(); }
      read(file, false);
   }

   ///
   @safe unittest
   {
      import std.file : remove;
      import std.stdio : File;

      auto configFile = File("test.conf", "w+");
      configFile.writeln("[Section 1]");
      configFile.writeln("key=value");
      configFile.writeln("\n[Section 2]");
      configFile.writeln("key2 = value");
      configFile.close();

      auto conf = new ConfigParser();
      conf.read("test.conf");

      assert(2 == conf.sections.length, "Incorrect Sections length");
      assert(true == conf.hasSection("Section 1"),
            "Config file doesn't have Section 1");
      assert(true == conf.hasOption("Section 1", "key"),
            "Config file doesn't have 'key' in 'Section 1'");

      remove("test.conf");
   }

   ///
   /// Parse a config file.
   ///
   /// Params:
   ///   file = Reference to the file from which to read.
   ///   close = Close the file when finished parsing.
   ///
   void read(ref File file, bool close = true) @trusted
   {
      import std.array : array;
      import std.algorithm.searching : canFind;
      import std.string : strip;

      scope(exit)
      {
         if (close)
         {
            file.close();
         }
      }

      foreach(const(char)[] inputLine; file.byLine)
      {
         const line = cast(string)strip(inputLine);

         if (line == "" || canFind(m_commentPrefixes, line[0]))
         {
            /* ignore empty lines or comments */
            continue;
         }

         if ('[' == line[0])
         {
            parseSectionHeader(line);
         }
         else
         {
            parseLine(line);
         }
      }
   }

   ///
   /// Parser configuration data from a string.
   ///
   void readString(string str) @safe pure
   {
      import std.algorithm.searching : canFind;
      import std.string : lineSplitter, strip;

      foreach(inputLine; lineSplitter(str))
      {
         const line = strip(inputLine);

         if ("" == line || canFind(m_commentPrefixes, line[0]))
         {
            /* ignore empty lines or comment lines */
            continue;
         }

         if ('[' == line[0])
         {
            parseSectionHeader(line);
         }
         else
         {
            parseLine(line);
         }
      }
   }

   ///
   @safe pure unittest
   {
      const input = "[section]
      option = value
      [second section]
      option = value";

      auto reader = new ConfigParser();
      reader.readString(input);

      assert(reader.hasSection("section"));
      assert(reader.hasSection("second section"));

      assert("value" == reader.get("section", "option"));
      assert("value" == reader.get("second section", "option"));
   }

   ///
   /// Get an `option` value for the named `section`.
   ///
   /// Params:
   ///   section = The section to look for the given `option`.
   ///   option = The option to return the value of
   ///   fallback = Fallback value if the `option` is not found. Can be null.
   ///
   /// Returns:
   ///   - The value for `option` if it is found.
   ///   - `null` if the `option` is not found and `fallback` is not provided.
   ///   - `fallback` if the `option` is not found and `fallback` is provided.
   ///
   /// Throws:
   ///   - NoSectionException if the `section` does not exist and no fallback is provided.
   ///   - NoOptionException if the `option` does not exist and no fallback is provided.
   ///
   string get(string section, string option) @safe pure const
   {
      import std.string : toLower;

      const lowercaseOption = toLower(option);

      if (false == this.hasSection(section))
      {
         throw new NoSectionException(section);
      }

      if (false == this.hasOption(section, lowercaseOption))
      {
         throw new NoOptionException(section, lowercaseOption);
      }

      return m_sections[section][lowercaseOption];
   }

   ///
   @safe pure unittest
   {
      import std.exception : assertThrown;

      auto conf = new ConfigParser();
      conf.addSection("Section");
      conf.set("Section", "option", "value");

      assert(conf.get("Section", "option") == "value");
      assertThrown!NoSectionException(conf.get("section", "option"));
      assertThrown!NoOptionException(conf.get("Section", "void"));
   }

   /// Ditto
   string get(string section, string option, string fallback) @safe pure const
   {
      try
      {
         return get(section, option);
      }
      catch (NoSectionException e)
      {
         return fallback;
      }
      catch (NoOptionException e)
      {
         return fallback;
      }
   }

   ///
   @safe pure unittest
   {
      import std.exception : assertThrown;

      auto conf = new ConfigParser();
      conf.addSection("Section");
      conf.set("Section", "option", "value");

      assert("value" == conf.get("Section", "option"));
      assert("fallback" == conf.get("section", "option", "fallback"));
      assert("fallback" == conf.get("Section", "void", "fallback"));

      /* can use null for fallback */
      assert(null is conf.get("section", "option", null));
      assert(null is conf.get("Section", "void", null));
   }

   ///
   /// A convenience method which parses the value of `option` in `section`
   /// to an integer.
   ///
   /// Params:
   ///   section = The section to look for the given `option`.
   ///   option = The option to return the value for.
   ///   fallback = The fallback value to use if `option` isn't found.
   ///
   /// Throws:
   ///   - NoSectionFoundException if `section` doesn't exist.
   ///   - NoOptionFoundException if the `section` doesn't contain `option`.
   ///   - ConvException if it failed to parse the value to an int.
   ///   - ConvOverflowException if the value would overflow an int.
   ///    /// See_Also: get()
   ///
   int getInt(string section, string option) @safe pure const
   {
      import std.conv : parse;

      auto res = get(section, option);

      return parse!int(res);
   }

   /// Ditto
   int getInt(string section, string option, int fallback) @safe pure const
   {
      try
      {
         return getInt(section, option);
      }
      catch (NoSectionException nse)
      {
         return fallback;
      }
      catch (NoOptionException noe)
      {
         return fallback;
      }
      catch (ConvException ce)
      {
         return fallback;
      }
   }

    /*
    double getDouble(string section, string option)
    {
    }

    double getDouble(string section, string option, double fallback)
    {
    }

    float getFloat(string section, string option)
    {
    }

    float getFloat(string section, string option, float fallback)
    {
    }*/

   ///
   /// A convenience method which coerces the $(I option) in the
   /// specified $(I section) to a boolean value.
   ///
   /// Note that the accepted values for the option are "1", "yes",
   /// "true", and "on", which cause this method to return `true`, and
   /// "0", "no", "false", and "off", which cause it to return `false`.
   ///
   /// These string values are checked in a case-insensitive manner.
   ///
   /// Params:
   ///   section = The section to look for the given option.
   ///   option = The option to return the value for.
   ///   fallback = The fallback value to use if the option was not found.
   ///
   /// Throws:
   ///   - NoSectionFoundException if `section` doesn't exist.
   ///   - NoOptionFoundException if the `section` doesn't contain `option`.
   ///   - ConvException if any other value was found.
   ///
   bool getBool(string section, string option) @safe pure const
   {
      import std.string : toLower;

      const value = get(section, option).toLower;

      switch (value)
      {
      case "1":
      case "yes":
      case "true":
      case "on":
         return true;
      case "0":
      case "no":
      case "false":
      case "off":
         return false;
      default:
         throw new ConvException("No valid boolean value found");
      }
   }

   /// Ditto
   bool getBool(string section, string option, bool fallback) @safe pure const
   {
      try
      {
         return getBool(section, option);
      }
      catch (NoSectionException e)
      {
         return fallback;
      }
      catch (NoOptionException e)
      {
         return fallback;
      }
      catch (ConvException e)
      {
         return fallback;
      }
   }

    /*
    string[string] items(string section)
    {
    }*/

   ///
   /// Remove the specified `option` from the specified `section`.
   ///
   /// Params:
   ///   section = The section to remove from.
   ///   option = The option to remove from section.
   ///
   /// Retruns:
   ///   `true` if option existed, false otherwise.
   ///
   /// Throws:
   ///  - NoSectionException if the specified section doesn't exist.
   ///
   bool removeOption(string section, string option) @safe pure
   {
      if ((section in m_sections) is null)
      {
         throw new NoSectionException(section);
      }

      if (option in m_sections[section])
      {
         m_sections[section].remove(option);
         return true;
      }

      return false;
   }

   ///
   @safe pure unittest
   {
      import std.exception : assertThrown;

      auto conf = new ConfigParser();
      conf.addSection("Default");
      conf.set("Default", "exists", "true");

      assertThrown!NoSectionException(conf.removeOption("void", "false"));
      assert(false == conf.removeOption("Default", "void"));
      assert(true == conf.removeOption("Default", "exists"));
   }

   ///
   /// Remove the specified `section` from the config.
   ///
   /// Params:
   ///   section = The section to remove.
   ///
   /// Returns:
   ///   `true` if the section existed, `false` otherwise.
   ///
   bool removeSection(string section) @safe pure
   {
      if (section in m_sections)
      {
         m_sections.remove(section);
         return true;
      }
      return false;
   }

   ///
   @safe pure unittest
   {
      auto conf = new ConfigParser();
      conf.addSection("Exists");
      assert(false == conf.removeSection("DoesNotExist"));
      assert(true == conf.removeSection("Exists"));
   }

   ///
   /// If the given $(I section) exists, set the given $(I option) to the
   /// specified $(I value).
   ///
   /// Throws:
   ///   - NoSectionException if the $(I section) does $(B not) exist.
   ///
   void set(string section, string option, string value) @safe pure
   {
      import std.string : toLower;

      if (false == this.hasSection(section))
      {
        throw new NoSectionException(section);
      }

      const lowercaseOption = toLower(option);
      m_sections[section][lowercaseOption] = value;
   }

   ///
   @safe pure unittest
   {
      import std.exception : assertThrown;

      auto conf = new ConfigParser();

      assertThrown!NoSectionException(conf.set("Section", "option", "value"));

      conf.addSection("Section");
      conf.set("Section", "option", "value");
      assert(conf.get("Section", "option") == "value");
   }

   ///
   /// Write a representation of the configuration to the
   /// provided *file*.
   ///
   /// This representation can be parsed by future calls to
   /// `read`.  This does **not** close the file after writing.
   ///
   /// Params:
   ///   file = An open file which was opened in text mode.
   ///   spaceAroundDelimiters = The delimiters between keys and
   ///                           values are surrounded by spaces.
   ///
   /// Note: Comments from the original file are not preserved when
   ///       writing the configuration back.
   ///
   void write(ref File file, bool spaceAroundDelimiters = true) @safe const
   {
      const del = spaceAroundDelimiters ? " = " : "=";

      foreach(const section, const options; m_sections)
      {
         file.writefln("[%s]", section);

         foreach(const option, const value; options)
         {
            file.writefln("%s%s%s", option, del, value);
         }
      }
   }

   ///
   @safe unittest
   {
      import std.file : remove;
      import std.stdio : File;

      auto writer = new ConfigParser();
      writer.addSection("general");

      writer.addSection("GUI");
      writer.set("GUI", "WINDOW_WIDTH", "848");
      writer.set("GUI", "WINDOW_HEIGHT", "480");

      auto file = File("test.ini", "w+");
      scope(exit) remove(file.name);
      writer.write(file);

      file.rewind();

      auto reader = new ConfigParser();
      reader.read(file);

      assert(reader.hasSection("general"), "reader does not contain general section");

      assert(reader.hasSection("GUI"), "reader does not contain GUI section");
      assert(reader.get("GUI", "WINDOW_WIDTH") == "848", "reader GUI.WINDOW_WIDTH is not 848");
      assert(reader.getInt("GUI", "WINDOW_WIDTH") == 848, "reader GUI.WINDOW_WIDTH is not 848 (int)");

      assert(reader.get("GUI", "WINDOW_HEIGHT") == "480", "reader GUI.WINDOW_HEIGHT is not 480");
      assert(reader.getInt("GUI", "WINDOW_HEIGHT") == 480, "reader GUI.WINDOW_HEIGHT is not 480 (int)");
   }

   ///
   /// Write a representation of the configuration to the
   /// specified *filename*.
   ///
   /// This representation can be parsed by future calls to
   /// `read`.  This does **not** close the file after writing.
   ///
   /// Params:
   ///   filename = The name of the file to write to.
   ///   spaceAroundDelimiters = The delimiters between keys and
   ///                           values are surrounded by spaces.
   ///
   /// Note: Comments from the original file are not preserved when
   ///       writing the configuration back.
   ///
   void write(string filename, bool spaceAroundDelimiters = true) @safe const
   {
      auto file = File(filename, "w+");
      write(file, spaceAroundDelimiters);
   }

   ///
   @safe unittest
   {
      import std.file : remove;

      enum kFilename = __PRETTY_FUNCTION__ ~ ".ini";

      auto writer = new ConfigParser();
      writer.addSection("general");
      writer.addSection("output");

      writer.set("general", "featureX", "true");
      writer.set("output", "featureX", "false");

      writer.write(kFilename);
      scope(exit) remove(kFilename);

      auto reader = new ConfigParser();
      reader.read(kFilename);

      assert(reader.hasSection("general"), "reader does not contain 'general' section");
      assert(reader.hasSection("output"), "reader does not contain 'output' section");

      assert(reader.getBool("general", "featureX") == true, "reader general.featureX is not true (bool)");
      assert(reader.getBool("output", "featureX") == false, "reader output.featureX is not false (bool)");
   }

   ///
   /// Write a representation of the configuration to the
   /// provided *buffer*.
   ///
   /// This representation can be parsed by future calls to
   /// `read`.
   ///
   /// Params:
   ///   buffer = An OutputRange to write to (i.e. std.OutBuffer).
   ///   spaceAroundDelimiters = The delimiters between keys and
   ///                           values are surrounded by spaces.
   ///
   /// Note: Comments from the original file are not preserved when
   ///       writing the configuration back.
   ///
   void write(T)(T buffer, bool spaceAroundDelimiters = true) @safe pure const
   if (isOutputRange!(T, string))
   {
      import std.format : format;

      const del = spaceAroundDelimiters ? " = " : "=";

      foreach(const section, const options; m_sections)
      {
         buffer.put(format!"[%s]\n"(section));

         foreach(const option, const value; options)
         {
            buffer.put(format!"%s%s%s\n"(option, del, value));
         }
      }
   }

   ///
   @safe pure unittest
   {
      import std.outbuffer : OutBuffer;

      OutBuffer buffer = new OutBuffer();
      ConfigParser writer = new ConfigParser();

      writer.addSection("general");
      writer.addSection("output");

      writer.set("general", "featureX", "false");
      writer.set("output", "featureX", "true");

      writer.write(buffer);

      ConfigParser reader = new ConfigParser();
      reader.readString(buffer.toString());

      assert(reader.hasSection("general"), "reader does not contain 'general' section.");
      assert(reader.hasSection("output"), "reader does not contain 'output' section.");

      assert(reader.getBool("general", "featureX") == false);
      assert(reader.getBool("output", "featureX") == true);
   }

private:

   void parseSectionHeader(const ref string line) @safe pure
   {
      import std.array : appender, assocArray;

      auto sectionHeader = appender!string;
      /* presume that the last character is ] */
      sectionHeader.reserve(line.length - 1);
      string popped = line[1 .. $];

      foreach(const c; popped)
      {
         if (c != ']')
         {
            sectionHeader.put(c);
         }
         else
         {
            break;
         }
      }

      m_currentSection = sectionHeader.data();

      if (m_currentSection in m_sections && m_strict)
      {
         throw new DuplicateSectionException(m_currentSection);
      }

      try
      {
         this.addSection(m_currentSection);
      }
      catch (DuplicateSectionException)
      {
         /* no-op - just making sure the section exists. */
      }
   }

   void parseLine(const ref string line) @safe pure
   {
      import std.string : indexOfAny, toLower, strip;

      const idx = line.indexOfAny(m_delimiters);

      if (-1 == idx)
      {
         return;
      }

      const option = line[0 .. idx].dup.strip.toLower;
      const string value = line[idx + 1 .. $].dup.strip;

      if (option in m_sections[m_currentSection] && m_strict)
      {
         throw new DuplicateOptionException(option, m_currentSection);
      }

      m_sections[m_currentSection][option] = value;
   }
}
